iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 25
0
Software Development

系統架構秘辛:了解RISC-V 架構底層除錯器的秘密!系列 第 25

Day 25: 您不可不知的FT2232H (2.5/3) - MPSSE Initial

  • 分享至 

  • xImage
  •  

0. 前言

經過昨天稍微解釋常用到的MPSSE Commands後,今天來做個總結!
來看看OpenOCD中如何實作FTDI-based Adapter的支援!
  
  
  

1. OpenOCD Adapter架構

首先來看一下,FTDI-Based Adapter的Interface(進入點),
參考以下範例(src/jtag/drivers/ftdi.c):

struct jtag_interface ftdi_interface = {
    .name = "ftdi",
    .supported = DEBUG_CAP_TMS_SEQ,
    .commands = ftdi_command_handlers,
    .transports = ftdi_transports,
    .swd = &ftdi_swd,

    .init = ftdi_initialize,
    .quit = ftdi_quit,
    .speed = ftdi_speed,
    .speed_div = ftdi_speed_div,
    .khz = ftdi_khz,
    .execute_queue = ftdi_execute_queue,
};

底下章節將會說明以下重要的兩個函式:

  • ftdi_initialize(): 用來執行Adapter的初始化,包含與Adapter之間USB的初始化、頻率設定等等
  • ftdi_execute_queue(): 最重要的核心函式,負責執行JTAG Queue中的Command!

2. Initialize - ftdi_initialize()

先看程式碼的部分,參考以下(src/jtag/drivers/ftdi.c):

static int ftdi_initialize(void)
{
    if (tap_get_tms_path_len(TAP_IRPAUSE, TAP_IRPAUSE) == 7)
        LOG_DEBUG("ftdi interface using 7 step jtag state transitions");
    else
        LOG_DEBUG("ftdi interface using shortest path jtag state transitions");

    ///[譯註] Step 1: FTDI USB Open
    for (int i = 0; ftdi_vid[i] || ftdi_pid[i]; i++) {
        mpsse_ctx = mpsse_open(&ftdi_vid[i], &ftdi_pid[i], ftdi_device_desc,
                ftdi_serial, ftdi_location, ftdi_channel);
        if (mpsse_ctx)
            break;
    }

    if (!mpsse_ctx)
        return ERROR_JTAG_INIT_FAILED;

    output = jtag_output_init;
    direction = jtag_direction_init;

    ....中間SWD支援的部分省略!


    ///[譯註] Step 2: MPSSE Initial & Config
    mpsse_set_data_bits_low_byte(mpsse_ctx, output & 0xff, direction & 0xff);
    mpsse_set_data_bits_high_byte(mpsse_ctx, output >> 8, direction >> 8);

    mpsse_loopback_config(mpsse_ctx, false);


    ///[譯註] Step 3: Set Adapter Frequency
    freq = mpsse_set_frequency(mpsse_ctx, jtag_get_speed_khz() * 1000);

    return mpsse_flush(mpsse_ctx);
}

這邊主要可以將整個初始化的流程,分成以下三步驟:

  1. FTDI USB Open
  2. MPSSE Initial & Config
  3. Set Adapter Frequency

底下分別說明這三部分!
  
  

2.1 FTDI USB Open

在原流程中,主要是依序帶入OpenOCD Config中設定好的那些USB VID/PID,然後透過呼叫mpsse_open(),開啟對應的Adapter!

在Config中,我可們可以發現以下內容(interface/olimex-arm-usb-tiny-h.cfg):

ftdi_vid_pid 0x15ba 0x002a <VID2> <PID2> <VID3> <PID3> ....

ftdi_vid_pid後面可以接上成對的VID和PID,以這邊的例子來說,
就是開啟 VID=0x15ba / PID=0x002a的那個Adapter(Olimex ARM-USB-TINY-H)!

接下來研究mpsse_open()的處理流程,參考以下(src/jtag/drivers/mpsse.c):

struct mpsse_ctx *mpsse_open(const uint16_t *vid, const uint16_t *pid, const char *description,
    const char *serial, const char *location, int channel)
{
    ///[譯註] Step 1: 初始化OpeOCD內部資料
    struct mpsse_ctx *ctx = calloc(1, sizeof(*ctx));
    int err;

    if (!ctx)
        return 0;

    bit_copy_queue_init(&ctx->read_queue);
    ctx->read_chunk_size = 16384;
    ctx->read_size = 16384;
    ctx->write_size = 16384;
    ctx->read_chunk = malloc(ctx->read_chunk_size);
    ctx->read_buffer = malloc(ctx->read_size);
    ctx->write_buffer = malloc(ctx->write_size);
    if (!ctx->read_chunk || !ctx->read_buffer || !ctx->write_buffer)
        goto error;

    ctx->interface = channel;
    ctx->index = channel + 1;
    ctx->usb_read_timeout = 5000;
    ctx->usb_write_timeout = 5000;

    err = libusb_init(&ctx->usb_ctx);
    if (err != LIBUSB_SUCCESS) {
        LOG_ERROR("libusb_init() failed with %s", libusb_error_name(err));
        goto error;
    }

    ///[譯註] Step 2: 開啟VID/PID對應的FTDI-based Adapter
    if (!open_matching_device(ctx, vid, pid, description, serial, location)) {
        /* Four hex digits plus terminating zero each */
        char vidstr[5];
        char pidstr[5];
        LOG_ERROR("unable to open ftdi device with vid %s, pid %s, description '%s', "
                "serial '%s' at bus location '%s'",
                vid ? sprintf(vidstr, "%04x", *vid), vidstr : "*",
                pid ? sprintf(pidstr, "%04x", *pid), pidstr : "*",
                description ? description : "*",
                serial ? serial : "*",
                location ? location : "*");
        ctx->usb_dev = 0;
        goto error;
    }


    err = libusb_control_transfer(ctx->usb_dev, FTDI_DEVICE_OUT_REQTYPE,
            SIO_SET_LATENCY_TIMER_REQUEST, 255, ctx->index, NULL, 0,
            ctx->usb_write_timeout);
    if (err < 0) {
        LOG_ERROR("unable to set latency timer: %s", libusb_error_name(err));
        goto error;
    }

    ///[譯註] Step 3: 將Adapter設定成MPSSE模式
    err = libusb_control_transfer(ctx->usb_dev,
            FTDI_DEVICE_OUT_REQTYPE,
            SIO_SET_BITMODE_REQUEST,
            0x0b | (BITMODE_MPSSE << 8),
            ctx->index,
            NULL,
            0,
            ctx->usb_write_timeout);
    if (err < 0) {
        LOG_ERROR("unable to set MPSSE bitmode: %s", libusb_error_name(err));
        goto error;
    }

    ///[譯註] Step 4: 清除FTDI晶片內部Tx/Rx Buffer
    mpsse_purge(ctx);

    return ctx;
error:
    mpsse_close(ctx);
    return 0;
}

mpsse_open()的處理流程也可以簡化成以下的步驟:

  1. 初始化OpeOCD內部資料
  2. 開啟VID/PID對應的FTDI-based Adapter
  3. 將Adapter設定成MPSSE模式
  4. 清除FTDI晶片內部Tx/Rx Buffer

首先是Step 1,初始化內部資料的部分:

    struct mpsse_ctx *ctx = calloc(1, sizeof(*ctx));
    int err;

    if (!ctx)
        return 0;

    bit_copy_queue_init(&ctx->read_queue);
    ctx->read_chunk_size = 16384;
    ctx->read_size = 16384;
    ctx->write_size = 16384;
    ctx->read_chunk = malloc(ctx->read_chunk_size);
    ctx->read_buffer = malloc(ctx->read_size);
    ctx->write_buffer = malloc(ctx->write_size);
    if (!ctx->read_chunk || !ctx->read_buffer || !ctx->write_buffer)
        goto error;

    ctx->interface = channel;
    ctx->index = channel + 1;
    ctx->usb_read_timeout = 5000;
    ctx->usb_write_timeout = 5000;

    err = libusb_init(&ctx->usb_ctx);
    if (err != LIBUSB_SUCCESS) {
        LOG_ERROR("libusb_init() failed with %s", libusb_error_name(err));
        goto error;
    }

基本上就是簡單的設定內部Tx/Rx Buffer、資料量上限、Timeout等等資料!

再來是Step 2,開啟對應VID/PID的Adapter,呼叫open_matching_device()來開啟USB通訊,內部主要是利用libusb相關的函式來做處理,重點是檢查VID/PID和Serial Number是否與Config內設定的相同,有興趣的讀者可以參考附錄的內容!

    if (!open_matching_device(ctx, vid, pid, description, serial, location)) {
        /* Four hex digits plus terminating zero each */
        char vidstr[5];
        char pidstr[5];
        LOG_ERROR("unable to open ftdi device with vid %s, pid %s, description '%s', "
                "serial '%s' at bus location '%s'",
                vid ? sprintf(vidstr, "%04x", *vid), vidstr : "*",
                pid ? sprintf(pidstr, "%04x", *pid), pidstr : "*",
                description ? description : "*",
                serial ? serial : "*",
                location ? location : "*");
        ctx->usb_dev = 0;
        goto error;
    }

再來是Step 3,將Adapter設定成MPSSE模式

    err = libusb_control_transfer(ctx->usb_dev,
            FTDI_DEVICE_OUT_REQTYPE,
            SIO_SET_BITMODE_REQUEST,
            0x0b | (BITMODE_MPSSE << 8),
            ctx->index,
            NULL,
            0,
            ctx->usb_write_timeout);

這部分的說明筆者要保留一下,查了很多文件&其他函式庫的實作後,還是不知道為啥這樣設定就可以開啟MPSSE Mode
尤其是筆者還是搞不懂為啥SIO_SET_BITMODE_REQUEST=0x11!?
歡迎留言告知一下!

底下放上參考資料:

如果是使用FTDI Close-source的D2XX Library,可以參考"D2XX Programmer's Guide"中的"FT_SetBitMode"

最後是Step 4,清除FTDI晶片內部Tx/Rx Buffer:

mpsse_purge(ctx);

這部分比較簡單,就是直接呼叫mpsse_purge()來處理!
可以參考以下內容(src/jtag/drivers/mpsse.c):

void mpsse_purge(struct mpsse_ctx *ctx)
{
    int err;
    LOG_DEBUG("-");
    ctx->write_count = 0;
    ctx->read_count = 0;
    ctx->retval = ERROR_OK;
    bit_copy_discard(&ctx->read_queue);
    err = libusb_control_transfer(ctx->usb_dev, FTDI_DEVICE_OUT_REQTYPE, SIO_RESET_REQUEST,
            SIO_RESET_PURGE_RX, ctx->index, NULL, 0, ctx->usb_write_timeout);
    if (err < 0) {
        LOG_ERROR("unable to purge ftdi rx buffers: %s", libusb_error_name(err));
        return;
    }

    err = libusb_control_transfer(ctx->usb_dev, FTDI_DEVICE_OUT_REQTYPE, SIO_RESET_REQUEST,
            SIO_RESET_PURGE_TX, ctx->index, NULL, 0, ctx->usb_write_timeout);
    if (err < 0) {
        LOG_ERROR("unable to purge ftdi tx buffers: %s", libusb_error_name(err));
        return;
    }
}

  
  

2.2 MPSSE Initial & Config

再來介紹ftdi_initialize()中的Step 2:

    ///[譯註] Step 2: MPSSE Initial & Config
    mpsse_set_data_bits_low_byte(mpsse_ctx, output & 0xff, direction & 0xff);
    mpsse_set_data_bits_high_byte(mpsse_ctx, output >> 8, direction >> 8);

    mpsse_loopback_config(mpsse_ctx, false);

當FT2232H進入MPSSE Mode後,就要來設定每根訊號線的初始值(Value)和方向(Direction),還記的上篇「Day 24: 您不可不知的FT2232H (2/3) - MPSSE Command Processor」中介紹到的"1.1 Set Data bits(High Byte/Low Byte)"嗎!?

先看一下Config內的設定(interface/olimex-arm-usb-tiny-h.cfg):

ftdi_layout_init 0x0808 0x0a1b

這邊就是將初始值設為0x0808,Direction設為0x0a1b,
可以對照成下表:

Pin # Function Value Direction
ADBUS0 TCK 0 1
ADBUS1 TDI 0 1
ADBUS2 TDO 0 0
ADBUS3 TMS 1 1
ADBUS4 ? 0 1
ADBUS5 0 0
ADBUS6 0 0
ADBUS7 0 0
ACBUS0 0 0
ACBUS1 nSRST 0 1
ACBUS2 0 0
ACBUS3 LED 1 1
ACBUS4 0 0
ACBUS5 0 0
ACBUS6 0 0
ACBUS7 0 0

Function內容是筆者自行推測的,Olimex並沒有釋出任何Schematic

再來就是呼叫mpsse_set_data_bits_low_byte()和mpsse_set_data_bits_high_byte()分別設定Low Byte的Value/Direction和High Byte的Value/Direction,
可以參考以下內容(src/jtag/drivers/mpsse.c):

void mpsse_set_data_bits_low_byte(struct mpsse_ctx *ctx, uint8_t data, uint8_t dir)
{
    DEBUG_IO("-");

    if (ctx->retval != ERROR_OK) {
        DEBUG_IO("Ignoring command due to previous error");
        return;
    }

    if (buffer_write_space(ctx) < 3)
        ctx->retval = mpsse_flush(ctx);

    buffer_write_byte(ctx, 0x80);
    buffer_write_byte(ctx, data);
    buffer_write_byte(ctx, dir);
}

void mpsse_set_data_bits_high_byte(struct mpsse_ctx *ctx, uint8_t data, uint8_t dir)
{
    DEBUG_IO("-");

    if (ctx->retval != ERROR_OK) {
        DEBUG_IO("Ignoring command due to previous error");
        return;
    }

    if (buffer_write_space(ctx) < 3)
        ctx->retval = mpsse_flush(ctx);

    buffer_write_byte(ctx, 0x82);
    buffer_write_byte(ctx, data);
    buffer_write_byte(ctx, dir);
}

以上述範例的Value=0x0808、Direction=0x0a1b來說,
就是送出以下兩筆Commands:

  • 0x80, 0x08, 0x1b
  • 0x82, 0x08, 0x0a

最後別忘記把loopback功能給關掉:

mpsse_loopback_config(mpsse_ctx, false);

void mpsse_loopback_config(struct mpsse_ctx *ctx, bool enable)
{
    LOG_DEBUG("%s", enable ? "on" : "off");
    single_byte_boolean_helper(ctx, enable, 0x84, 0x85);
}

就是簡單的送出"0x85",關閉Loopback!
  
  

2.3 Set Adapter Frequency

最後,就是要來設定JTAG中TCK的頻率:

    ///[譯註] Step 3: Set Adapter Frequency
    freq = mpsse_set_frequency(mpsse_ctx, jtag_get_speed_khz() * 1000);

一樣,來看一下mpsse_set_frequency()的內容,
請參考以下(src/jtag/drivers/mpsse.c):

int mpsse_set_frequency(struct mpsse_ctx *ctx, int frequency)
{
    LOG_DEBUG("target %d Hz", frequency);
    assert(frequency >= 0);
    int base_clock;

    if (frequency == 0)
        return mpsse_rtck_config(ctx, true);

    mpsse_rtck_config(ctx, false); /* just try */

    if (frequency > 60000000 / 2 / 65536 && mpsse_divide_by_5_config(ctx, false) == ERROR_OK) {
        base_clock = 60000000;
    } else {
        mpsse_divide_by_5_config(ctx, true); /* just try */
        base_clock = 12000000;
    }

    int divisor = (base_clock / 2 + frequency - 1) / frequency - 1;
    if (divisor > 65535)
        divisor = 65535;
    assert(divisor >= 0);

    mpsse_set_divisor(ctx, divisor);

    frequency = base_clock / 2 / (1 + divisor);
    LOG_DEBUG("actually %d Hz", frequency);

    return frequency;
}

這邊就比較簡單啦!!!

首先是判斷Frequency有沒有設定,在OpenOCD中,如果Frequency為0,表示開啟RTCK的功能:

    if (frequency == 0)
        return mpsse_rtck_config(ctx, true);

不過RTCK在這系列中並不會提到,先跳過!

如果是正常的使用JTAG中的TCK,那就需要把FT2232H的RTCK給關閉(src/jtag/drivers/mpsse.c):

mpsse_rtck_config(ctx, false); /* just try */

int mpsse_rtck_config(struct mpsse_ctx *ctx, bool enable)
{
    if (!mpsse_is_high_speed(ctx))
        return ERROR_FAIL;

    LOG_DEBUG("%s", enable ? "on" : "off");
    single_byte_boolean_helper(ctx, enable, 0x96, 0x97);

    return ERROR_OK;
}

就是簡單的送出"0x97",來關閉RTCK功能!

接下來是要判斷Base Clock的部分!
FT2232H中支援60 MHz和12 MHz(Divide by 5)兩種Base Clock:

    if (frequency > 60000000 / 2 / 65536 && mpsse_divide_by_5_config(ctx, false) == ERROR_OK) {
        base_clock = 60000000;
    } else {
        mpsse_divide_by_5_config(ctx, true); /* just try */
        base_clock = 12000000;
    }

如果要使用12 MHz,就呼叫mpsse_divide_by_5_config(),送出"0x8b",來開啟"Divide by 5"的功能(src/jtag/drivers/mpsse.c):

int mpsse_divide_by_5_config(struct mpsse_ctx *ctx, bool enable)
{
    if (!mpsse_is_high_speed(ctx))
        return ERROR_FAIL;

    LOG_DEBUG("%s", enable ? "on" : "off");
    single_byte_boolean_helper(ctx, enable, 0x8b, 0x8a);

    return ERROR_OK;
}

接下來是計算除數:

    int divisor = (base_clock / 2 + frequency - 1) / frequency - 1;
    if (divisor > 65535)
        divisor = 65535;
    assert(divisor >= 0);

然後呼叫mpsse_set_divisor(),把除數寫入(src/jtag/drivers/mpsse.c):

void mpsse_set_divisor(struct mpsse_ctx *ctx, uint16_t divisor)
{
    LOG_DEBUG("%d", divisor);

    if (ctx->retval != ERROR_OK) {
        DEBUG_IO("Ignoring command due to previous error");
        return;
    }

    if (buffer_write_space(ctx) < 3)
        ctx->retval = mpsse_flush(ctx);

    buffer_write_byte(ctx, 0x86);
    buffer_write_byte(ctx, divisor & 0xff);
    buffer_write_byte(ctx, divisor >> 8);
}

這邊就是把算好的Divisor拆成ValueL, ValueH,依照"0x86, 0xValueL, 0xValueH"的格式送出!
  
  
  

99. 結語

打到一半才發現,光講完FT2232H初始化的部分,就已經篇幅太長了....
所以只好將JTAG Command Execution放到明天~!

讓我們明天再會啦!!
  
  
  

A. 附錄 open_matching_device()

static bool open_matching_device(struct mpsse_ctx *ctx, const uint16_t *vid, const uint16_t *pid,
    const char *product, const char *serial, const char *location)
{
    libusb_device **list;
    struct libusb_device_descriptor desc;
    struct libusb_config_descriptor *config0;
    int err;
    bool found = false;
    ssize_t cnt = libusb_get_device_list(ctx->usb_ctx, &list);
    if (cnt < 0)
        LOG_ERROR("libusb_get_device_list() failed with %s", libusb_error_name(cnt));

    for (ssize_t i = 0; i < cnt; i++) {
        libusb_device *device = list[i];

        err = libusb_get_device_descriptor(device, &desc);
        if (err != LIBUSB_SUCCESS) {
            LOG_ERROR("libusb_get_device_descriptor() failed with %s", libusb_error_name(err));
            continue;
        }

        if (vid && *vid != desc.idVendor)
            continue;
        if (pid && *pid != desc.idProduct)
            continue;

        err = libusb_open(device, &ctx->usb_dev);
        if (err != LIBUSB_SUCCESS) {
            LOG_ERROR("libusb_open() failed with %s",
                  libusb_error_name(err));
            continue;
        }

        if (location && !device_location_equal(device, location)) {
            libusb_close(ctx->usb_dev);
            continue;
        }

        if (product && !string_descriptor_equal(ctx->usb_dev, desc.iProduct, product)) {
            libusb_close(ctx->usb_dev);
            continue;
        }

        if (serial && !string_descriptor_equal(ctx->usb_dev, desc.iSerialNumber, serial)) {
            libusb_close(ctx->usb_dev);
            continue;
        }

        found = true;
        break;
    }

    libusb_free_device_list(list, 1);

    if (!found) {
        LOG_ERROR("no device found");
        return false;
    }

    err = libusb_get_config_descriptor(libusb_get_device(ctx->usb_dev), 0, &config0);
    if (err != LIBUSB_SUCCESS) {
        LOG_ERROR("libusb_get_config_descriptor() failed with %s", libusb_error_name(err));
        libusb_close(ctx->usb_dev);
        return false;
    }

    /* Make sure the first configuration is selected */
    int cfg;
    err = libusb_get_configuration(ctx->usb_dev, &cfg);
    if (err != LIBUSB_SUCCESS) {
        LOG_ERROR("libusb_get_configuration() failed with %s", libusb_error_name(err));
        goto error;
    }

    if (desc.bNumConfigurations > 0 && cfg != config0->bConfigurationValue) {
        err = libusb_set_configuration(ctx->usb_dev, config0->bConfigurationValue);
        if (err != LIBUSB_SUCCESS) {
            LOG_ERROR("libusb_set_configuration() failed with %s", libusb_error_name(err));
            goto error;
        }
    }

    /* Try to detach ftdi_sio kernel module */
    err = libusb_detach_kernel_driver(ctx->usb_dev, ctx->interface);
    if (err != LIBUSB_SUCCESS && err != LIBUSB_ERROR_NOT_FOUND
            && err != LIBUSB_ERROR_NOT_SUPPORTED) {
        LOG_WARNING("libusb_detach_kernel_driver() failed with %s, trying to continue anyway",
            libusb_error_name(err));
    }

    err = libusb_claim_interface(ctx->usb_dev, ctx->interface);
    if (err != LIBUSB_SUCCESS) {
        LOG_ERROR("libusb_claim_interface() failed with %s", libusb_error_name(err));
        goto error;
    }

    /* Reset FTDI device */
    err = libusb_control_transfer(ctx->usb_dev, FTDI_DEVICE_OUT_REQTYPE,
            SIO_RESET_REQUEST, SIO_RESET_SIO,
            ctx->index, NULL, 0, ctx->usb_write_timeout);
    if (err < 0) {
        LOG_ERROR("failed to reset FTDI device: %s", libusb_error_name(err));
        goto error;
    }

    switch (desc.bcdDevice) {
    case 0x500:
        ctx->type = TYPE_FT2232C;
        break;
    case 0x700:
        ctx->type = TYPE_FT2232H;
        break;
    case 0x800:
        ctx->type = TYPE_FT4232H;
        break;
    case 0x900:
        ctx->type = TYPE_FT232H;
        break;
    default:
        LOG_ERROR("unsupported FTDI chip type: 0x%04x", desc.bcdDevice);
        goto error;
    }

    /* Determine maximum packet size and endpoint addresses */
    if (!(desc.bNumConfigurations > 0 && ctx->interface < config0->bNumInterfaces
            && config0->interface[ctx->interface].num_altsetting > 0))
        goto desc_error;

    const struct libusb_interface_descriptor *descriptor;
    descriptor = &config0->interface[ctx->interface].altsetting[0];
    if (descriptor->bNumEndpoints != 2)
        goto desc_error;

    ctx->in_ep = 0;
    ctx->out_ep = 0;
    for (int i = 0; i < descriptor->bNumEndpoints; i++) {
        if (descriptor->endpoint[i].bEndpointAddress & 0x80) {
            ctx->in_ep = descriptor->endpoint[i].bEndpointAddress;
            ctx->max_packet_size =
                    descriptor->endpoint[i].wMaxPacketSize;
        } else {
            ctx->out_ep = descriptor->endpoint[i].bEndpointAddress;
        }
    }

    if (ctx->in_ep == 0 || ctx->out_ep == 0)
        goto desc_error;

    libusb_free_config_descriptor(config0);
    return true;

desc_error:
    LOG_ERROR("unrecognized USB device descriptor");
error:
    libusb_free_config_descriptor(config0);
    libusb_close(ctx->usb_dev);
    return false;
}

參考資料

  1. FT2232H Dual High Speed USB to Multipurpose UART/FIFO IC Datasheet Version 2.5
  2. AN_108 Command Processor for MPSSE and MCU Host Bus Emulation Modes

上一篇
Day 24: 您不可不知的FT2232H (2/3) - MPSSE Command Processor
下一篇
Day 26: 您不可不知的FT2232H (3/3) - MPSSE & JTAG Control
系列文
系統架構秘辛:了解RISC-V 架構底層除錯器的秘密!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言